리프레시 토큰 사용 방법
11/15/2024
리프레시 토큰
리프레시 토큰(Refresh Token)은 액세스 토큰이 만료 되었을 때, 클라이언트가 새로 로그인하지 않고도 새로운 액세스 토큰을 발급받기 위한 용도로 사용된다.
만료된 토큰 처리
JWT는 만료 시간이 지나면 더 이상 유효하지 않기 때문에, 만료된 토큰을 처리하는 로직이 필요하다. 이를 위해 리프레시 토큰(Refresh Token)을 사용하는 방법이 일반적이다.
리프레시 토큰 사용 흐름
- 엑세스 토큰: 짧은 유효기간을 가지며, 만료되면 클라이언트는 서버로부터 새로운 액세스 토큰을 발급 받아야 한다.
- 리프레시 토큰: 더 긴 유효기간을 가지며, 액세스 토큰이 만료 되었을 때, 클라이언트가 서버에 리프레시 토큰을 보내어 새로운 액세스 토큰을 발급받는다.
구현 방법
리프레시 토큰을 구현하기 위해서는:
- 액세스 토큰과 라프레시 토큰을 함께 발급.
- 리프레시 토큰을 저장하고, 액세스 토큰이 만료되었을 때 이를 통해 새로운 액세스 토큰을 발급하는 라우트를 추가.
코드 예시
서버코드(app.js)
const express = require('express');
const jwt = require('jsonwebtoken');
const bodyParser = require('body-parser');
const path = require('path');
const app = express();
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());
app.use(express.static('public'));
const ACCESS_TOKEN_SECRET = 'accessTokenSecret';
const REFRESH_TOKEN_SECRET = 'refreshTokenSecret';
// 리프레시 토큰 저장소 (실제 구현에서는 데이터베이스를 사용해야 한다)
const refreshTokens = new Set();
// 사용자 데이터베이스 (예시)
const users = {
'user1': 'password1',
'user2': 'password2'
};
// 로그인 페이지 라우트
app.get('/login', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'login.html'));
});
// 로그인 처리 (POST 요청)
app.post('/login', (req, res) => {
const { username, password } = req.body;
if (users[username] && users[username] === password) {
const accessToken = generateAccessToken(username);
const refreshToken = generateRefreshToken(username);
refreshTokens.add(refreshToken);
res.json({ accessToken, refreshToken });
} else {
res.status(401).send('로그인 실패! 사용자 이름 또는 비밀번호가 잘못되었습니다.');
}
});
// 액세스 토큰 생성 함수
function generateAccessToken(username) {
return jwt.sign({ username }, ACCESS_TOKEN_SECRET, { expiresIn: '1m' });
}
// 리프레시 토큰 생성 함수
function generateRefreshToken(username) {
return jwt.sign({ username }, REFRESH_TOKEN_SECRET, { expiresIn: '7d' });
}
// 토큰 갱신 엔드포인트
app.post('/token', (req, res) => {
const { refreshToken } = req.body;
console.log('리프레시 토큰 요청 받음:', refreshToken);
if (!refreshToken || !refreshTokens.has(refreshToken)) {
console.log('유효하지 않은 리프레시 토큰');
return res.status(403).json({ error: '유효하지 않은 리프레시 토큰입니다.' });
}
jwt.verify(refreshToken, REFRESH_TOKEN_SECRET, (err, user) => {
if (err) {
console.log('리프레시 토큰 검증 실패:', err);
return res.status(403).json({ error: '리프레시 토큰 검증 실패' });
}
const accessToken = generateAccessToken(user.username);
console.log('새 액세스 토큰 생성:', accessToken);
res.json({ accessToken });
});
});
// 로그아웃 처리
app.post('/logout', (req, res) => {
const { refreshToken } = req.body;
refreshTokens.delete(refreshToken);
res.sendStatus(204);
});
// 대시보드 페이지 라우트 (HTML 파일 제공)
app.get('/dashboard', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'dashboard.html'));
});
// 대시보드 API 엔드포인트 (인증 필요)
app.get('/api/dashboard', authenticateToken, (req, res) => {
res.json({ message: `환영합니다, ${req.user.username}님!` });
});
// 미들웨어: 액세스 토큰 검증
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: '액세스 토큰이 필요합니다.' });
}
jwt.verify(token, ACCESS_TOKEN_SECRET, (err, user) => {
if (err) {
return res.status(403).json({ error: '유효하지 않은 액세스 토큰입니다.' });
}
req.user = user;
next();
});
}
app.listen(3000, () => {
console.log('서버가 http://localhost:3000 에서 실행 중입니다.');
});
로그인페이지(public/login.html)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>로그인</title>
</head>
<body>
<h2>로그인</h2>
<form id="loginForm">
<label>사용자 이름:</label>
<input type="text" id="username" />
<label>비밀번호:</label>
<input type="password" id="password" />
<button type="submit">로그인</button>
</form>
<script>
document.getElementById('loginForm').addEventListener('submit', async function(event) {
event.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
try {
const response = await fetch('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
if (!response.ok) {
throw new Error('로그인 실패');
}
const data = await response.json();
// 액세스 토큰과 리프레시 토큰을 로컬 스토리지에 저장
localStorage.setItem('accessToken', data.accessToken);
localStorage.setItem('refreshToken', data.refreshToken);
alert('로그인 성공! 대시보드로 이동합니다.');
window.location.href = '/dashboard.html';
} catch (error) {
alert(error.message);
}
});
</script>
</body>
</html>
대시보드페이지(public/dashboard)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>대시보드</title>
</head>
<body>
<h2>대시보드</h2>
<p id="welcomeMessage"></p>
<button id="logoutButton">로그아웃</button>
<script>
async function loadDashboard() {
const accessToken = localStorage.getItem('accessToken');
const refreshToken = localStorage.getItem('refreshToken');
if (!accessToken || !refreshToken) {
console.log('토큰이 없습니다. 로그인 페이지로 이동합니다.');
window.location.href = '/login.html';
return;
}
try {
const response = await fetch('/api/dashboard', {
method: 'GET',
headers: {
'Authorization': 'Bearer ' + accessToken
}
});
if (response.status === 403) {
console.log('액세스 토큰이 만료되었습니다. 새 토큰을 요청합니다.');
const newAccessToken = await refreshAccessToken(refreshToken);
if (newAccessToken) {
console.log('새 액세스 토큰을 받았습니다. 대시보드를 다시 로드합니다.');
localStorage.setItem('accessToken', newAccessToken);
return loadDashboard(); // 새로운 액세스 토큰으로 다시 시도
}
}
if (!response.ok) {
throw new Error('대시보드 접근 실패');
}
const data = await response.json();
console.log('대시보드 로드 성공:', data.message);
document.getElementById('welcomeMessage').innerText = data.message;
} catch (error) {
console.error('오류 발생:', error.message);
alert(error.message);
window.location.href = '/login.html';
}
}
async function refreshAccessToken(refreshToken) {
try {
const response = await fetch('/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken })
});
if (!response.ok) {
throw new Error('토큰 갱신 실패');
}
const data = await response.json();
return data.accessToken;
} catch (error) {
console.error('리프레시 토큰 사용 실패:', error);
return null;
}
}
document.getElementById('logoutButton').addEventListener('click', async () => {
const refreshToken = localStorage.getItem('refreshToken');
try {
await fetch('/logout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken })
});
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
window.location.href = '/login.html';
} catch (error) {
console.error('로그아웃 실패:', error);
}
});
loadDashboard();
</script>
</body>
</html>
부분 설명
리프레시 토큰 저장소
const refreshTokens = new Set();
- app.js에서 주석으로 표시한 것과 같이, 실제 서비스에서는 데이터베이스에 저장하는 것이 좋다고 한다.
새로운 액세스 토큰 발급 라우트
async function refreshAccessToken(refreshToken) {
try {
const response = await fetch('/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken })
});
- 클라이언트는
/token경로로 POST 요청을 보내며, 본문에 리프레시 토큰을 포함해야 한다. - 서버는 해당 리프레시 토큰이 유효한지 확인하고, 유효하다면 새로운 액세스 토큰을 발급한다.
로그아웃 처리
try {
await fetch('/logout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken })
});
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
- 클라이언트가 로그아웃할 때
/logout경로로 POST 요청을 보내며 본문에 리프레시 토큰을 포함한다. - 서버는 해당 리프레시 토큰을 삭제하여 더 이상 사용할 수 없도록 한다.
블로그 내 관련 문서
참고 자료
출처 :